Value Objects y Agregados
El dominio se construye a partir de tres tipos de objetos con responsabilidades distintas:
- Value Object — sin identidad propia, inmutable, se compara por valor. Encapsula un concepto semántico de dominio.
- Entidad — tiene ID propio, puede cambiar a lo largo de su ciclo de vida manteniendo su identidad.
- Aggregate Root — entidad que actúa como único punto de entrada a un grupo de objetos relacionados.
A continuación analizaremos todos estos en profundidad.
Value Objects: tipos con semántica
Un Value Object encapsula un concepto de dominio en un tipo explícito que garantiza, si existe, que es válido. En lugar de pasar String email o Long entityId como primitivos sueltos, se crea un tipo que:
- Valida en su propio constructor — un solo punto de validación, no IFs dispersos.
- Atrae lógica relacionada — el tipo se convierte en el lugar natural para comportamiento asociado al concepto.
- Da significado semántico —
Emailaporta más información queString.
// Ejemplo ilustrativo del patrón Value Object
public final class Email {
private final String value;
public Email(final String value) {
if (!value.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
this.value = value;
}
public String domain() { return value.substring(value.indexOf('@') + 1); }
public String value() { return value; }
}Email garantiza que cualquier instancia es válida: no puede existir un Email malformado en el sistema. domain() ilustra el "imán de lógica" — el método vive en el propio tipo, no disperso en servicios.
Entidades: objetos con identidad
Una entidad tiene un identificador que la hace única a lo largo de su ciclo de vida. Dos entidades con el mismo id son la misma entidad aunque todos sus demás atributos difieran.
La clase Description es la entidad central del módulo:
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Description {
private String description;
private Long id;
private Long typeId;
private Long entityId;
private Long userId;
private Date timestamp;
}Tiene identidad (id), tiene ciclo de vida (se crea, se actualiza, se elimina). Es una entidad correcta desde el punto de vista estructural.
Aggregate Root: el guardián del agregado
El Aggregate Root es una entidad que actúa como único punto de entrada a un grupo de objetos relacionados. Su función es doble:
- Guardar las invariantes del dominio — es el único lugar donde se pueden aplicar reglas de negocio que afectan al conjunto.
- Definir la barrera transaccional — todo lo que está dentro del agregado se persiste junto; lo que está fuera se gestiona por separado.
En el módulo de descripciones, Description es prácticamente el Aggregate Root de su propio agregado. El agregado es sencillo — no hay entidades internas bajo él — pero las reglas siguen siendo válidas: para crear una descripción siempre se debe pasar por la entidad, nunca construir los datos manualmente desde fuera.
La Ley de Demeter
Una consecuencia del AR como único punto de entrada es la Ley de Demeter, también conocida como el "principio del menor conocimiento". La formulación clásica dice que un método solo debe invocar a sus vecinos inmediatos: sus propios campos, sus parámetros, los objetos que él mismo construye, y los colaboradores que le han sido inyectados. Atravesar el grafo de objetos para alcanzar un dato lejano produce un train wreck — una cadena de llamadas que acopla al llamante con la estructura interna de objetos que no son suyos. Cualquier cambio en cualquiera de los eslabones de la cadena rompe al llamante.
// ❌ Incorrecto: navegar propiedades internas desde fuera
description.getTimestamp().before(new Date());
// ✅ Correcto: preguntar al AR con intención
description.isExpired();El AR es el guardián de su propia estructura: si alguien necesita una respuesta sobre el estado interno, debe pedirla con un método con intención. La forma del método cuenta una historia del dominio (isExpired); la cadena de getters cuenta solo cómo está implementado el AR por dentro.
La Ley de Demeter aplicada al bounded context
La misma regla, subida un nivel de abstracción, gobierna la relación entre módulos. Cada bounded context se comporta como un agregado cuyos vecinos directos son sus propias proyecciones, nunca las tablas de otro módulo. Cuando DescriptionService necesita el nombre legible de un recurso para componer una NamedDescription, no escala el grafo hasta el módulo origen — pregunta al mirror que tiene en casa:
// description-app/.../DescriptionService.java
public String getEntityName(final DescriptionEntity descriptionEntity, final long id) {
return switch (descriptionEntity.getEntity()) {
case "VARIABLE" -> descriptionVariableRepository.getClientReference(id);
case "TAG" -> descriptionTagRepository.getName(id);
case "QUERY" -> descriptionQueryRepository.getName(id);
case "RULE" -> descriptionRuleRepository.getName(id);
case "USER" -> descriptionUserRepository.getName(id);
case "GROUP" -> descriptionGroupRepository.getName(id);
case "FILE" -> descriptionFileRepository.getFileId(id);
case "CUSTOMER" -> descriptionCustomerRepository.getName();
default -> "ERROR";
};
}Cada descriptionXxxRepository es un colaborador inyectado en el propio servicio: un vecino directo. La alternativa prohibida sería navegar al módulo de variables, pedir la entidad Variable completa y de ahí el nombre — un train wreck que además cruzaría la frontera entre bounded contexts y traería al módulo de descripciones campos que no le incumben (unidades, rangos, configuración…). La Ley de Demeter y el principio de no-cruce entre BCs son la misma idea aplicada en escalas distintas: no atravieses estructuras que no son tuyas.